using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using ContentPatcher.Framework.Conditions;
using ContentPatcher.Framework.ConfigModels;
using ContentPatcher.Framework.Constants;
using ContentPatcher.Framework.Lexing;
using ContentPatcher.Framework.Lexing.LexTokens;
using ContentPatcher.Framework.Patches;
using ContentPatcher.Framework.TextOperations;
using ContentPatcher.Framework.Tokens;
using ContentPatcher.Framework.Tokens.Json;
using Newtonsoft.Json.Linq;
using Pathoschild.Stardew.Common.Utilities;
using StardewModdingAPI;
using StardewModdingAPI.Events;
using StardewModdingAPI.Utilities;
using StardewValley;

namespace ContentPatcher.Framework
{
    /// <summary>Handles loading and unloading patches for content packs.</summary>
    internal class PatchLoader
    {
        /*********
        ** Fields
        *********/
        /// <summary>Manages loaded patches.</summary>
        private readonly PatchManager PatchManager;

        /// <summary>Manages loaded tokens.</summary>
        private readonly TokenManager TokenManager;

        /// <summary>The monitor through which to log errors.</summary>
        private readonly IMonitor Monitor;

        /// <summary>The installed mod IDs.</summary>
        private readonly IInvariantSet InstalledMods;

        /// <summary>Parse an asset name so it's consistent with those generated by the game.</summary>
        /// <remarks>This shouldn't be used directly. See <see cref="ParseAssetName"/> instead, which applies Content Patcher-specific logic.</remarks>
        private readonly Func<string, IAssetName> ParseAssetNameImpl;

        /// <summary>Handles parsing raw strings into tokens.</summary>
        private readonly Lexer Lexer = new();


        /*********
        ** Public methods
        *********/
        /// <summary>Construct an instance.</summary>
        /// <param name="patchManager">Manages loaded patches.</param>
        /// <param name="tokenManager">Manages loaded tokens.</param>
        /// <param name="monitor">The monitor through which to log errors.</param>
        /// <param name="installedMods">The installed mod IDs.</param>
        /// <param name="parseAssetName">Parse an asset name so it's consistent with those generated by the game.</param>
        public PatchLoader(PatchManager patchManager, TokenManager tokenManager, IMonitor monitor, IInvariantSet installedMods, Func<string, IAssetName> parseAssetName)
        {
            this.PatchManager = patchManager;
            this.TokenManager = tokenManager;
            this.Monitor = monitor;
            this.InstalledMods = installedMods;
            this.ParseAssetNameImpl = parseAssetName;
        }

        /// <summary>Load patches for a content pack.</summary>
        /// <param name="contentPack">The content pack for which to load patches.</param>
        /// <param name="rawPatches">The raw patches to load.</param>
        /// <param name="rootIndexPath">The path of indexes from the root <c>content.json</c> to the root which is loading patches; see <see cref="IPatch.IndexPath"/>.</param>
        /// <param name="path">The path to the patches from the root content file.</param>
        /// <param name="parentPatch">The parent <see cref="PatchType.Include"/> patch for which the patches are being loaded, if any.</param>
        /// <returns>Returns the patches that were loaded.</returns>
        public IEnumerable<IPatch> LoadPatches(RawContentPack contentPack, PatchConfig[] rawPatches, int[] rootIndexPath, LogPathBuilder path, Patch? parentPatch)
        {
            bool verbose = this.Monitor.IsVerbose;

            // get fake patch context (so patch tokens are available in patch validation)
            ModTokenContext modContext = this.TokenManager.TrackLocalTokens(contentPack.ContentPack);
            LocalContext fakePatchContext = new LocalContext(contentPack.Manifest.UniqueID, parentContext: modContext);
            foreach (ConditionType type in InternalConstants.FromFileTokens.Concat(InternalConstants.TargetTokens))
                fakePatchContext.SetLocalValue(type.ToString(), InternalConstants.TokenPlaceholder);

            // get token parser for fake context
            TokenParser tokenParser = new TokenParser(fakePatchContext, contentPack.Manifest, contentPack.Migrator, this.InstalledMods);

            // preprocess patches
            PatchConfig[] patches = this.SplitPatches(rawPatches).ToArray();
            if (!patches.Any())
                return [];
            this.UniquelyNamePatches(patches);

            // apply patch-list migrations
            // lower-level migrations are applied in LoadPatch below
            if (!contentPack.Migrator.TryMigrate(ref patches, out string? error))
            {
                this.Monitor.Log($"Ignored {path}: {error}", LogLevel.Warn);
                return [];
            }

            // load patches
            int index = -1;
            IList<IPatch> loadedPatches = new List<IPatch>(patches.Length);
            foreach (PatchConfig patch in patches)
            {
                index++;
                var localPath = path.With(patch.LogName!);
                if (verbose)
                    this.Monitor.Log($"   loading {localPath}...");
                IPatch? loaded = this.LoadPatch(contentPack, patch, tokenParser, rootIndexPath.Concat([index]).ToArray(), localPath, parentPatch, logSkip: reasonPhrase => this.Monitor.Log($"Ignored {localPath}: {reasonPhrase}", LogLevel.Warn));
                if (loaded != null)
                    loadedPatches.Add(loaded);
            }

            return loadedPatches;
        }

        /// <summary>Unload patches loaded (directly or indirectly) by the given patch.</summary>
        /// <param name="parentPatch">The parent patch for which to unload descendants.</param>
        public void UnloadPatchesLoadedBy(IPatch parentPatch)
        {
            this.UnloadPatches(patch => this.IsDescendant(parent: parentPatch, child: patch));
        }

        /// <summary>Unload patches loaded (directly or indirectly) by the given content pack.</summary>
        /// <param name="pack">The content pack for which to unload descendants.</param>
        public void UnloadPatchesLoadedBy(RawContentPack pack)
        {
            this.UnloadPatches(patch => patch.ContentPack == pack.ContentPack);
        }

        /// <summary>Normalize and parse the given condition values.</summary>
        /// <param name="raw">The raw condition values to normalize.</param>
        /// <param name="tokenParser">Handles low-level parsing and validation for tokens.</param>
        /// <param name="path">The path to the value from the root content file.</param>
        /// <param name="conditions">The normalized conditions.</param>
        /// <param name="immutableRequiredModIDs">The immutable mod IDs always required by these conditions (if they're <see cref="ConditionType.HasMod"/> and immutable).</param>
        /// <param name="error">An error message indicating why normalization failed.</param>
        public bool TryParseConditions(IDictionary<string, string?>? raw, TokenParser tokenParser, LogPathBuilder path, out Condition[] conditions, out IInvariantSet immutableRequiredModIDs, [NotNullWhen(false)] out string? error)
        {
            // no conditions
            if (raw == null || !raw.Any())
            {
                immutableRequiredModIDs = InvariantSets.Empty;
                conditions = [];
                error = null;
                return true;
            }

            // parse conditions
            MutableInvariantSet requiredModIds = [];
            InvariantDictionary<Condition> parsed = new();
            foreach ((string key, string? value) in raw.OrderBy(p => this.GetConditionParseOrder(p.Key, p.Value)))
            {
                if (!this.TryParseCondition(key, value, tokenParser, path.With(key), out Condition? condition, ref requiredModIds, out error))
                {
                    immutableRequiredModIDs = InvariantSets.Empty;
                    conditions = [];
                    return false;
                }

                parsed[key] = condition;
            }

            immutableRequiredModIDs = requiredModIds.Lock();
            conditions = parsed.Values.ToArray();
            error = null;
            return true;
        }

        /// <summary>Normalize and parse the given update rate.</summary>
        /// <param name="raw">The raw update rate to normalize.</param>
        /// <param name="updateRate">The normalized update rate.</param>
        /// <param name="error">An error message indicating why parsing failed.</param>
        public bool TryParseUpdateRate(string? raw, out UpdateRate updateRate, [NotNullWhen(false)] out string? error)
        {
            // base update rate
            updateRate = UpdateRate.OnDayStart;
            if (string.IsNullOrWhiteSpace(raw))
            {
                error = null;
                return true;
            }

            // parse each value
            string[] rawParts = raw.Split(',');
            foreach (string part in rawParts)
            {
                if (!Enum.TryParse(part, ignoreCase: true, out UpdateRate parsed))
                {
                    error = $"Invalid {nameof(PatchConfig.Update)} value '{part}', expected one of: {string.Join(", ", Enum.GetNames(typeof(UpdateRate)))}";
                    return false;
                }

                updateRate |= parsed;
            }

            error = null;
            return true;
        }


        /*********
        ** Private methods
        *********/
        /// <summary>Parse a raw asset name.</summary>
        /// <param name="rawName">The raw asset name to parse.</param>
        /// <exception cref="ArgumentException">The <paramref name="rawName"/> is null or empty.</exception>
        private IAssetName ParseAssetName(string rawName)
        {
            if (rawName.EndsWith(".xnb", StringComparison.OrdinalIgnoreCase))
                rawName = rawName[..^4]; // maintain legacy behavior

            return this.ParseAssetNameImpl(rawName);
        }

        /// <summary>Split patches with multiple <see cref="PatchConfig.Target"/> and/or <see cref="PatchConfig.FromFile"/> values.</summary>
        /// <param name="patches">The patches to split.</param>
        private IEnumerable<PatchConfig> SplitPatches(IEnumerable<PatchConfig> patches)
        {
            foreach (PatchConfig patch in patches)
            {
                // split target and from-file values
                string[] targets = this.Lexer.SplitLexically(patch.Target).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
                string[] fromFiles = this.Lexer.SplitLexically(patch.FromFile).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();

                // no split needed
                if (targets.Length <= 1 && fromFiles.Length <= 1)
                {
                    // strip unneeded commas from split fields
                    // (Mod authors intuitively expect "a," to be the same as "a", but Content Patcher doesn't treat
                    // these fields as sets after this split is done, so "a," would become the literal value.)
                    patch.Target = targets.FirstOrDefault() ?? patch.Target;
                    patch.FromFile = fromFiles.FirstOrDefault() ?? patch.FromFile;

                    yield return patch;
                    continue;
                }

                // if `targets` or `fromFile` are missing, we still need to iterate one null value
                // to populate the split patches.
                IEnumerable<string?> AlwaysIterate(string[] values)
                {
                    if (values.Length > 0)
                        return values;

                    return [null];
                }

                // create new patches
                foreach (string? target in AlwaysIterate(targets))
                {
                    foreach (string? fromFile in AlwaysIterate(fromFiles))
                    {
                        // create patch
                        var newPatch = new PatchConfig(patch)
                        {
                            Target = target,
                            FromFile = fromFile
                        };

                        // add descriptive log name
                        if (string.IsNullOrWhiteSpace(newPatch.LogName))
                            newPatch.LogName = this.GetDefaultPatchName(newPatch);
                        else
                        {
                            List<string> labels = [];

                            if (targets.Length > 1)
                                labels.Add($"{target}");
                            if (fromFiles.Length > 1)
                                labels.Add($"from {fromFile}");

                            if (labels.Any())
                                newPatch.LogName += $" ({string.Join(" ", labels)})";
                        }

                        yield return newPatch;
                    }
                }
            }
        }

        /// <summary>Set a unique name for all patches in a content pack.</summary>
        /// <param name="patches">The patches to name.</param>
        private void UniquelyNamePatches(PatchConfig[] patches)
        {
            // add default log names
            foreach (PatchConfig patch in patches)
            {
                if (string.IsNullOrWhiteSpace(patch.LogName))
                    patch.LogName = this.GetDefaultPatchName(patch);
            }

            // make names unique within content pack
            foreach (var patchGroup in patches.GroupBy(p => p.LogName, StringComparer.OrdinalIgnoreCase).Where(p => p.Count() > 1))
            {
                int i = 0;
                foreach (PatchConfig patch in patchGroup)
                    patch.LogName += $" #{++i}";
            }
        }

        /// <summary>Get the default name for a patch, without accounting for unique discriminators.</summary>
        /// <param name="patch">The patch to name.</param>
        private string GetDefaultPatchName(PatchConfig patch)
        {
            // default name if valid
            if (Enum.TryParse(patch.Action, ignoreCase: true, out PatchType type))
            {
                string? path = type == PatchType.Include
                    ? patch.FromFile
                    : patch.Target;

                return !string.IsNullOrWhiteSpace(path)
                    ? $"{type} {PathUtilities.NormalizeAssetName(path)}"
                    : $"{type} invalid";
            }

            // invalid patch with missing required fields
            return !string.IsNullOrWhiteSpace(patch.Action)
                ? $"{patch.Action}"
                : "invalid";
        }

        /// <summary>Unload patches matching a condition.</summary>
        /// <param name="where">Matches patches to unload.</param>
        private void UnloadPatches(Func<IPatch, bool> where)
        {
            IPatch[] removePatches = this.PatchManager.GetPatches().Where(where).ToArray();
            if (removePatches.Any())
            {
                foreach (IPatch patch in removePatches)
                    this.PatchManager.Remove(patch);
            }
        }

        /// <summary>Load one patch from a content pack's <c>content.json</c> file.</summary>
        /// <param name="rawContentPack">The content pack being loaded.</param>
        /// <param name="entry">The change to load.</param>
        /// <param name="tokenParser">Handles low-level parsing and validation for tokens.</param>
        /// <param name="indexPath">The path of indexes from the root <c>content.json</c> to this patch; see <see cref="IPatch.IndexPath"/>.</param>
        /// <param name="path">The path to the patch from the root content file.</param>
        /// <param name="parentPatch">The parent <see cref="PatchType.Include"/> patch for which the patches are being loaded, if any.</param>
        /// <param name="logSkip">The callback to invoke with the error reason if loading it fails.</param>
        /// <returns>The patch that was loaded, or <c>null</c> if it failed to load.</returns>
        private IPatch? LoadPatch(RawContentPack rawContentPack, PatchConfig entry, TokenParser tokenParser, int[] indexPath, LogPathBuilder path, Patch? parentPatch, Action<string> logSkip)
        {
            var pack = rawContentPack.ContentPack;
            PatchType? action = null;

            IPatch? TrackSkip(string reason, bool warn = true)
            {
                reason = reason.TrimEnd('.', ' ');
                this.PatchManager.AddPermanentlyDisabled(new DisabledPatch(path, entry.Action, action, entry.Target, entry.TargetLocale, pack, parentPatch, reason));
                if (warn)
                    logSkip(reason + '.');
                return null;
            }

            try
            {
                // parse action
                {
                    if (!Enum.TryParse(entry.Action, true, out PatchType parsedAction))
                    {
                        return TrackSkip(string.IsNullOrWhiteSpace(entry.Action)
                            ? $"must set the {nameof(PatchConfig.Action)} field"
                            : $"invalid {nameof(PatchConfig.Action)} value '{entry.Action}', expected one of: {string.Join(", ", Enum.GetNames(typeof(PatchType)))}"
                        );
                    }
                    action = parsedAction;
                }

                // parse conditions
                Condition[] conditions;
                IInvariantSet immutableRequiredModIDs;
                {
                    if (!this.TryParseConditions(entry.When, tokenParser, path.With(nameof(entry.When)), out conditions, out immutableRequiredModIDs, out string? error))
                        return TrackSkip($"the {nameof(PatchConfig.When)} field is invalid: {error}");
                }

                // parse target asset
                IManagedTokenString? targetAsset = null;
                {
                    if (string.IsNullOrWhiteSpace(entry.Target))
                    {
                        if (action != PatchType.Include)
                            return TrackSkip($"must set the {nameof(PatchConfig.Target)} field");
                    }
                    else if (!tokenParser.TryParseString(entry.Target, immutableRequiredModIDs, path.With(nameof(entry.Target)), out string? error, out targetAsset))
                        return TrackSkip($"the {nameof(PatchConfig.Target)} is invalid: {error}");
                }

                // patch target asset locale
                IManagedTokenString? targetAssetLocale = null;
                {
                    if (entry.TargetLocale != null && !tokenParser.TryParseString(entry.TargetLocale, immutableRequiredModIDs, path.With(nameof(entry.TargetLocale)), out string? error, out targetAssetLocale))
                        return TrackSkip($"the {nameof(PatchConfig.TargetLocale)} is invalid: {error}");
                }

                // parse 'enabled'
                bool enabled = true;
                if (entry.Enabled != null)
                {
                    bool isEnabledAllowed = rawContentPack.Content.Format!.IsOlderThan("1.25.0");
                    if (!isEnabledAllowed)
                    {
                        if (!bool.TryParse(entry.Enabled, out bool raw) || !raw) // special case: if it's just the literal value "true", ignore it instead of breaking the content pack
                            return TrackSkip($"the {nameof(PatchConfig.Enabled)} field is obsolete and should be removed");
                    }

                    if (!this.TryParseEnabled(entry.Enabled, tokenParser, immutableRequiredModIDs, path.With(nameof(entry.Enabled)), out string? error, out enabled))
                        return TrackSkip($"invalid {nameof(PatchConfig.Enabled)} value '{entry.Enabled}': {error}");
                }

                // parse update rate
                UpdateRate updateRate;
                {
                    if (!this.TryParseUpdateRate(entry.Update, out updateRate, out string? error))
                        return TrackSkip(error);
                }

                // parse 'from file'
                IManagedTokenString? fromAsset = null;
                if (entry.FromFile != null)
                {
                    if (!this.TryPrepareLocalAsset(entry.FromFile, tokenParser, immutableRequiredModIDs, path.With(nameof(entry.FromFile)), out string? error, out fromAsset))
                        return TrackSkip(error);
                }

                // validate field reference tokens
                if (targetAsset != null)
                {
                    if (targetAsset.UsesTokens(InternalConstants.TargetTokens))
                        return TrackSkip($"circular field reference: {nameof(entry.Target)} field can't use the '{string.Join("', '", InternalConstants.TargetTokens)}' tokens.");
                }
                if (fromAsset != null)
                {
                    if (fromAsset.UsesTokens(InternalConstants.FromFileTokens))
                        return TrackSkip($"circular field reference: {nameof(entry.FromFile)} field can't use the '{string.Join("', '", InternalConstants.FromFileTokens)}' tokens.");
                    if (fromAsset.UsesTokens(InternalConstants.TargetTokens) && targetAsset?.UsesTokens(InternalConstants.FromFileTokens) == true)
                        return TrackSkip($"circular field reference: {nameof(entry.Target)} field can't use the '{string.Join("', '", InternalConstants.FromFileTokens)}' tokens if the {nameof(entry.FromFile)} field uses '{string.Join("', '", InternalConstants.TargetTokens)}' tokens.");
                }

                // get patch instance
                IPatch patch;
                switch (action)
                {
                    // include
                    case PatchType.Include:
                        {
                            // validate
                            if (fromAsset == null)
                                return TrackSkip($"must set the {nameof(PatchConfig.FromFile)} field for an {PatchType.Include} patch.");
                            if (targetAsset != null)
                                return TrackSkip($"can't use the {nameof(PatchConfig.Target)} field with an {PatchType.Include} patch.");

                            // save
                            patch = new IncludePatch(
                                indexPath: indexPath,
                                path: path,
                                conditions: conditions,
                                fromFile: fromAsset,
                                updateRate: updateRate,
                                contentPack: rawContentPack,
                                parentPatch: parentPatch,
                                parseAssetName: this.ParseAssetName,
                                monitor: this.Monitor,
                                patchLoader: this
                            );
                        }
                        break;

                    // load asset
                    case PatchType.Load:
                        {
                            // validate
                            if (fromAsset == null)
                                return TrackSkip($"must set the {nameof(PatchConfig.FromFile)} field for a {PatchType.Load} patch.");

                            // parse priority
                            if (!this.TryParsePriority(entry, AssetLoadPriority.Exclusive, out AssetLoadPriority priority, out string? error))
                                return TrackSkip(error);

                            // save
                            patch = new LoadPatch(
                                indexPath: indexPath,
                                path: path,
                                assetName: targetAsset!,
                                assetLocale: targetAssetLocale,
                                priority: priority,
                                updateRate: updateRate,
                                conditions: conditions,
                                localAsset: fromAsset,
                                contentPack: pack,
                                migrator: rawContentPack.Migrator,
                                parentPatch: parentPatch,
                                parseAssetName: this.ParseAssetName
                            );
                        }
                        break;

                    // edit data
                    case PatchType.EditData:
                        {
                            // validate
                            bool fromFileAllowed = rawContentPack.Content.Format!.IsOlderThan("1.18.0");
                            bool missingRequiredFields = !entry.Entries.Any() && !entry.Fields.Any() && !entry.MoveEntries.Any() && !entry.TextOperations.Any();
                            if (fromFileAllowed)
                            {
                                if (missingRequiredFields && fromAsset == null)
                                    return TrackSkip($"one of {nameof(PatchConfig.Entries)}, {nameof(PatchConfig.Fields)}, {nameof(PatchConfig.MoveEntries)}, {nameof(PatchConfig.TextOperations)}, or {nameof(PatchConfig.FromFile)} must be specified for an '{action}' change");
                                if (fromAsset != null && (entry.Entries.Any() || entry.Fields.Any() || entry.MoveEntries.Any()))
                                    return TrackSkip($"{nameof(PatchConfig.FromFile)} is mutually exclusive with {nameof(PatchConfig.Entries)}, {nameof(PatchConfig.Fields)}, and {nameof(PatchConfig.MoveEntries)}");
                            }
                            else
                            {
                                if (fromAsset != null)
                                    return TrackSkip($"the {nameof(PatchConfig.FromFile)} field can't be used with an '{action}' patch");
                                if (missingRequiredFields)
                                    return TrackSkip($"one of {nameof(PatchConfig.Entries)}, {nameof(PatchConfig.Fields)}, {nameof(PatchConfig.MoveEntries)}, or {nameof(PatchConfig.TextOperations)} must be specified for an '{action}' change");
                            }

                            // parse data changes
                            bool TryParseFields(IContext context, PatchConfig rawFields, out List<EditDataPatchRecord> parsedEntries, out List<EditDataPatchField> parsedFields, out List<EditDataPatchMoveRecord> parsedMoveEntries, out List<IManagedTokenString> targetField, [NotNullWhen(false)] out string? setParseError)
                            {
                                return this.TryParseEditDataFields(rawFields, tokenParser, immutableRequiredModIDs, path, out parsedEntries, out parsedFields, out parsedMoveEntries, out targetField, out setParseError);
                            }
                            List<EditDataPatchRecord>? entries = null;
                            List<EditDataPatchField>? fields = null;
                            List<EditDataPatchMoveRecord>? moveEntries = null;
                            List<IManagedTokenString>? targetField = null;
                            if (fromAsset == null && !TryParseFields(tokenParser.Context, entry, out entries, out fields, out moveEntries, out targetField, out string? error))
                                return TrackSkip(error);

                            // parse priority
                            if (!this.TryParsePriority(entry, AssetEditPriority.Default, out AssetEditPriority priority, out error))
                                return TrackSkip(error);

                            // parse text operations
                            if (!this.TryParseTextOperations(entry, tokenParser, immutableRequiredModIDs, path.With(nameof(entry.TextOperations)), out IList<ITextOperation> textOperations, out error))
                                return TrackSkip(error);

                            // save
                            patch = new EditDataPatch(
                                indexPath: indexPath,
                                path: path,
                                assetName: targetAsset!,
                                assetLocale: targetAssetLocale,
                                priority: priority,
                                conditions: conditions,
                                fromFile: fromAsset,
                                records: entries,
                                fields: fields,
                                moveRecords: moveEntries,
                                textOperations: textOperations,
                                targetField: targetField,
                                updateRate: updateRate,
                                contentPack: pack,
                                migrator: rawContentPack.Migrator,
                                parentPatch: parentPatch,
                                monitor: this.Monitor,
                                parseAssetName: this.ParseAssetName,
                                tryParseFields: TryParseFields
                            );
                        }
                        break;

                    // edit image
                    case PatchType.EditImage:
                        {
                            // validate
                            if (fromAsset == null)
                                return TrackSkip($"must set the {nameof(PatchConfig.FromFile)} field for a {PatchType.Load} patch.");

                            // read patch mode
                            PatchImageMode patchMode = PatchImageMode.Replace;
                            if (!string.IsNullOrWhiteSpace(entry.PatchMode) && !Enum.TryParse(entry.PatchMode, true, out patchMode))
                                return TrackSkip($"the {nameof(PatchConfig.PatchMode)} is invalid. Expected one of these values: [{string.Join(", ", Enum.GetNames(typeof(PatchImageMode)))}]");

                            // read from area
                            TokenRectangle? fromArea = null;
                            if (entry.FromArea != null && !this.TryParseRectangle(entry.FromArea, tokenParser, immutableRequiredModIDs, path.With(nameof(entry.FromArea)), out string? error, out fromArea))
                                return TrackSkip(error);

                            // read to area
                            TokenRectangle? toArea = null;
                            if (entry.ToArea != null && !this.TryParseRectangle(entry.ToArea, tokenParser, immutableRequiredModIDs, path.With(nameof(entry.ToArea)), out error, out toArea))
                                return TrackSkip(error);

                            // parse priority
                            if (!this.TryParsePriority(entry, AssetEditPriority.Default, out AssetEditPriority priority, out error))
                                return TrackSkip(error);

                            // save
                            patch = new EditImagePatch(
                                indexPath: indexPath,
                                path: path,
                                assetName: targetAsset!,
                                assetLocale: targetAssetLocale,
                                priority: priority,
                                conditions: conditions,
                                fromAsset: fromAsset,
                                fromArea: fromArea,
                                toArea: toArea,
                                patchMode: patchMode,
                                updateRate: updateRate,
                                contentPack: pack,
                                migrator: rawContentPack.Migrator,
                                parentPatch: parentPatch,
                                monitor: this.Monitor,
                                parseAssetName: this.ParseAssetName
                            );
                        }
                        break;

                    // edit map
                    case PatchType.EditMap:
                        {
                            string? error;

                            // read map properties
                            var mapProperties = new List<EditMapPatchProperty>();
                            foreach (var pair in entry.MapProperties)
                            {
                                LogPathBuilder localPath = path.With(nameof(entry.MapProperties), pair.Key);

                                if (!tokenParser.TryParseString(pair.Key, immutableRequiredModIDs, localPath.With("key"), out error, out IManagedTokenString? key))
                                    return TrackSkip($"{nameof(PatchConfig.MapProperties)} > '{pair.Key}' key is invalid: {error}");
                                if (!tokenParser.TryParseNullableString(pair.Value, immutableRequiredModIDs, localPath.With("value"), out error, out IManagedTokenString? value))
                                    return TrackSkip($"{nameof(PatchConfig.MapProperties)} > '{pair.Key}' value '{pair.Value}' is invalid: {error}");

                                mapProperties.Add(new EditMapPatchProperty(key, value));
                            }

                            // read map tiles
                            var mapTiles = new List<EditMapPatchTile>();
                            for (int i = 0; i < entry.MapTiles.Count; i++)
                            {
                                PatchMapTileConfig? tile = entry.MapTiles[i];
                                if (tile is null)
                                    continue;

                                LogPathBuilder localPath = path.With(nameof(entry.MapTiles), i.ToString());
                                string errorPrefix = $"{nameof(PatchConfig.MapTiles)} > entry #{i + 1}";

                                // layer
                                if (!tokenParser.TryParseString(tile.Layer, immutableRequiredModIDs, localPath.With(nameof(tile.Layer)), out error, out IManagedTokenString? layer))
                                    return TrackSkip($"{errorPrefix} > {nameof(EditMapPatchTile.Layer)} is invalid: {error}");

                                // position
                                if (!this.TryParsePosition(tile.Position, tokenParser, immutableRequiredModIDs, localPath.With(nameof(tile.Position)), out error, out TokenPosition? position))
                                    return TrackSkip($"{errorPrefix} > {nameof(EditMapPatchTile.Position)} is invalid: {error}");

                                // tilesheet
                                IManagedTokenString? tilesheet = null;
                                if (tile.SetTilesheet != null && !tokenParser.TryParseString(tile.SetTilesheet, immutableRequiredModIDs, localPath.With(nameof(tile.SetTilesheet)), out error, out tilesheet))
                                    return TrackSkip($"{errorPrefix} > {nameof(EditMapPatchTile.SetTilesheet)} is invalid: {error}");

                                // index
                                IManagedTokenString? setIndex = null;
                                if (tile.SetIndex != null && !this.TryParseInt(tile.SetIndex, tokenParser, immutableRequiredModIDs, localPath.With(nameof(tile.SetIndex)), out error, out setIndex))
                                    return TrackSkip($"{errorPrefix} > {nameof(EditMapPatchTile.SetIndex)} is invalid: {error}");

                                // properties
                                var tileProperties = new Dictionary<IManagedTokenString, IManagedTokenString?>();
                                {
                                    int p = 0;
                                    foreach (var pair in tile.SetProperties)
                                    {
                                        p++;
                                        if (!tokenParser.TryParseString(pair.Key, immutableRequiredModIDs, localPath.With(nameof(tile.SetProperties), "key"), out error, out IManagedTokenString? key))
                                            return TrackSkip($"{errorPrefix} > {nameof(EditMapPatchTile.SetProperties)} > entry #{p + 1} > key is invalid: {error}");
                                        if (!tokenParser.TryParseNullableString(pair.Value, immutableRequiredModIDs, localPath.With(nameof(tile.SetProperties), "value"), out error, out IManagedTokenString? value))
                                            return TrackSkip($"{errorPrefix} > {nameof(EditMapPatchTile.SetProperties)} > entry #{p + 1} > value is invalid: {error}");

                                        tileProperties[key] = value;
                                    }
                                }

                                // remove
                                IManagedTokenString? remove = null;
                                if (tile.Remove != null && !this.TryParseBoolean(tile.Remove, tokenParser, immutableRequiredModIDs, localPath.With(nameof(tile.Remove)), out error, out remove))
                                    return TrackSkip($"{errorPrefix} > {nameof(EditMapPatchTile.Remove)} is invalid: {error}");

                                mapTiles.Add(new EditMapPatchTile(
                                    layer: layer,
                                    position: position,
                                    setIndex: setIndex,
                                    setTilesheet: tilesheet,
                                    setProperties: tileProperties,
                                    remove: remove
                                ));
                            }

                            // parse warps
                            var addWarps = new List<IManagedTokenString>();
                            for (int i = 0; i < entry.AddWarps.Count; i++)
                            {
                                LogPathBuilder localPath = path.With(nameof(entry.AddWarps), i.ToString());
                                if (!tokenParser.TryParseString(entry.AddWarps[i], immutableRequiredModIDs, localPath, out string? warpError, out IManagedTokenString? parsed))
                                    return TrackSkip($"{nameof(PatchConfig.AddWarps)} > '{entry.AddWarps[i]}' is invalid: {warpError}");
                                addWarps.Add(parsed);
                            }

                            // parse text operations
                            if (!this.TryParseTextOperations(entry, tokenParser, immutableRequiredModIDs, path.With(nameof(entry.TextOperations)), out IList<ITextOperation> textOperations, out error))
                                return TrackSkip(error);

                            // read from/to asset areas
                            TokenRectangle? fromArea = null;
                            if (entry.FromArea != null && !this.TryParseRectangle(entry.FromArea, tokenParser, immutableRequiredModIDs, path.With(nameof(entry.FromArea)), out error, out fromArea))
                                return TrackSkip(error);
                            TokenRectangle? toArea = null;
                            if (entry.ToArea != null && !this.TryParseRectangle(entry.ToArea, tokenParser, immutableRequiredModIDs, path.With(nameof(entry.ToArea)), out error, out toArea))
                                return TrackSkip(error);

                            // read patch mode
                            PatchMapMode patchMode = PatchMapMode.ReplaceByLayer;
                            if (!string.IsNullOrWhiteSpace(entry.PatchMode) && !Enum.TryParse(entry.PatchMode, true, out patchMode))
                                return TrackSkip($"the {nameof(PatchConfig.PatchMode)} is invalid. Expected one of these values: [{string.Join(", ", Enum.GetNames(typeof(PatchMapMode)))}]");

                            // validate
                            if (fromAsset == null && !mapProperties.Any() && !mapTiles.Any() && !addWarps.Any() && !textOperations.Any())
                                return TrackSkip($"must specify at least one of {nameof(entry.AddWarps)}, {nameof(entry.FromFile)}, {nameof(entry.MapProperties)}, {nameof(entry.MapTiles)}, or {nameof(entry.TextOperations)}");

                            // parse priority
                            if (!this.TryParsePriority(entry, AssetEditPriority.Default, out AssetEditPriority priority, out error))
                                return TrackSkip(error);

                            // save
                            patch = new EditMapPatch(
                                indexPath: indexPath,
                                path: path,
                                assetName: targetAsset!,
                                assetLocale: targetAssetLocale,
                                priority: priority,
                                conditions: conditions,
                                fromAsset: fromAsset,
                                fromArea: fromArea,
                                toArea: toArea,
                                patchMode: patchMode,
                                mapProperties: mapProperties,
                                mapTiles: mapTiles,
                                addWarps: addWarps,
                                textOperations: textOperations,
                                updateRate: updateRate,
                                contentPack: pack,
                                migrator: rawContentPack.Migrator,
                                parentPatch: parentPatch,
                                monitor: this.Monitor,
                                parseAssetName: this.ParseAssetName
                            );
                        }
                        break;

                    default:
                        return TrackSkip($"unsupported patch type '{action}'");
                }

                // skip if not enabled
                // note: we process the patch even if it's disabled, so any errors are caught by the mod author instead of only failing after the patch is enabled.
                if (!enabled)
                    return TrackSkip($"{nameof(PatchConfig.Enabled)} is false", warn: false);

                // validate high-level issues
                {
                    var tokensUsed = InvariantSets.From(patch.GetTokensUsed());

                    // any field uses {{FromFile}} without a FromFile field
                    foreach (ConditionType token in InternalConstants.FromFileTokens)
                    {
                        if (tokensUsed.Contains(token.ToString()) && patch.RawFromAsset == null)
                            return TrackSkip($"can't use the {{{{{token}}}}} token because the patch has no {nameof(PatchConfig.FromFile)} field.");
                    }

                    // any field uses {{Target*}} without a Target field
                    foreach (ConditionType type in InternalConstants.TargetTokens)
                    {
                        if (tokensUsed.Contains(type.ToString()) && patch.RawTargetAsset == null)
                            return TrackSkip($"can't use the {{{{{type}}}}} token because the patch has no {nameof(PatchConfig.Target)} field.");
                    }
                }

                // save patch
                this.PatchManager.Add(patch);
                return patch;
            }
            catch (Exception ex)
            {
                return TrackSkip($"error reading info. Technical details:\n{ex}");
            }
        }

        /// <summary>Parse the priority field for a patch.</summary>
        /// <typeparam name="TPriority">The priority type (one of <see cref="AssetEditPriority"/> or <see cref="AssetLoadPriority"/>).</typeparam>
        /// <param name="patch">The patch whose priority to parse.</param>
        /// <param name="defaultValue">The default priority if not specified.</param>
        /// <param name="priority">The parsed priority value, if valid.</param>
        /// <param name="error">The error message indicating why parsing failed, if applicable.</param>
        /// <returns>Returns whether parsing succeeded.</returns>
        private bool TryParsePriority<TPriority>(PatchConfig patch, TPriority defaultValue, out TPriority priority, [NotNullWhen(false)] out string? error)
            where TPriority : struct
        {
            // default if omitted
            if (string.IsNullOrWhiteSpace(patch.Priority))
            {
                priority = defaultValue;
                error = null;
                return true;
            }

            // parse as enum value
            if (Utility.TryParseEnum(patch.Priority, out TPriority newPriority))
            {
                priority = newPriority;
                error = null;
                return true;
            }

            // parse as an offset like 'Medium + 10'
            int splitAt = patch.Priority.IndexOfAny(['-', '+']);
            if (splitAt > 0)
            {
                string rawPriority = patch.Priority[..splitAt];
                char rawSign = patch.Priority[splitAt];
                string rawOffset = patch.Priority[(splitAt + 1)..];

                if (Utility.TryParseEnum(rawPriority, out newPriority) && int.TryParse(rawOffset, out int offset))
                {
                    if (rawSign == '-')
                        offset *= -1;

                    priority = (TPriority)(object)((int)(object)newPriority + offset);
                    error = null;
                    return true;
                }
            }

            priority = defaultValue;
            error = $"the {nameof(patch.Priority)} value '{patch.Priority}' is invalid: expected one of [{string.Join(", ", Enum.GetNames(typeof(TPriority)))}] or a simple offset like '{Activator.CreateInstance(typeof(TPriority))} + 10'";
            return false;
        }

        /// <summary>Parse the text operation fields for an <see cref="PatchType.EditData"/> or <see cref="PatchType.EditMap"/> patch.</summary>
        /// <param name="patch">The patch whose text operations to parse.</param>
        /// <param name="tokenParser">Handles low-level parsing and validation for tokens.</param>
        /// <param name="assumeModIds">Mod IDs to assume are installed for purposes of token validation.</param>
        /// <param name="path">The path to the value from the root content file.</param>
        /// <param name="textOperations">The parsed text operations.</param>
        /// <param name="error">The error message indicating why parsing failed, if applicable.</param>
        /// <returns>Returns whether parsing succeeded.</returns>
        private bool TryParseTextOperations(PatchConfig patch, TokenParser tokenParser, IInvariantSet assumeModIds, LogPathBuilder path, out IList<ITextOperation> textOperations, [NotNullWhen(false)] out string? error)
        {
            bool Fail(string reason, out string outReason)
            {
                outReason = reason;
                return false;
            }

            // get empty list
            textOperations = new List<ITextOperation>();
            if (!patch.TextOperations.Any())
            {
                error = null;
                return true;
            }

            // parse entries
            int i = 0;
            foreach (TextOperationConfig? operation in patch.TextOperations)
            {
                if (operation is null)
                    continue;

                LogPathBuilder localPath = path.With(i++.ToString());
                string errorPrefix = $"{nameof(patch.TextOperations)} > {i} is invalid";

                // parse type
                if (!Enum.TryParse(operation.Operation, true, out TextOperationType operationType))
                {
                    return Fail(
                        string.IsNullOrWhiteSpace(operation.Operation)
                            ? $"{errorPrefix}: the {nameof(operation.Operation)} must be set"
                            : $"{errorPrefix}: invalid {nameof(operation.Operation)} value '{operation.Operation}', expected one of: {string.Join(", ", Enum.GetNames(typeof(TextOperationType)))}",
                        out error
                    );
                }

                // parse target
                List<IManagedTokenString> target = [];
                foreach (string? field in operation.Target)
                {
                    if (!tokenParser.TryParseString(field, assumeModIds, localPath.With(nameof(TextOperationConfig.Target), i.ToString()), out string? targetError, out IManagedTokenString? parsed))
                        return Fail($"{errorPrefix}: the {nameof(operation.Target)} value '{field}' couldn't be parsed: {targetError}", out error);
                    target.Add(parsed);
                }
                if (target.Count == 0)
                    return Fail($"{errorPrefix}: the {nameof(operation.Target)} value must be set.", out error);
                if (target.Count == 1)
                    return Fail($"{errorPrefix}: the {nameof(operation.Target)} value must specify at least two segments.", out error);

                // parse value
                if (!tokenParser.TryParseString(operation.Value, assumeModIds, localPath.With(nameof(operation.Value)), out string? valueError, out IManagedTokenString? value))
                    return Fail($"{errorPrefix}: the {nameof(operation.Value)} value '{operation.Value}' couldn't be parsed: {valueError}", out error);

                // parse search
                if (!tokenParser.TryParseString(operation.Search, assumeModIds, localPath.With(nameof(operation.Search)), out string? searchError, out IManagedTokenString? search))
                    return Fail($"{errorPrefix}: the {nameof(operation.Search)} value '{operation.Search}' couldn't be parsed: {searchError}", out error);

                // parse replace mode
                TextOperationReplaceMode replaceMode;
                if (operation.ReplaceMode is null)
                    replaceMode = TextOperationReplaceMode.All;
                else if (!Enum.TryParse(operation.ReplaceMode, true, out replaceMode))
                    return Fail($"{errorPrefix}: invalid {nameof(operation.ReplaceMode)} value '{operation.ReplaceMode}', expected one of: {string.Join(", ", Enum.GetNames(typeof(TextOperationReplaceMode)))}", out error);

                // get operation instance
                ITextOperation parsedOperation;
                switch (operationType)
                {
                    case TextOperationType.Append:
                    case TextOperationType.Prepend:
                        parsedOperation = new AppendOrPrependTextOperation(
                            operation: operationType,
                            target: target,
                            value: value,
                            delimiter: operation.Delimiter
                        );
                        break;

                    case TextOperationType.RemoveDelimited:
                        if (string.IsNullOrEmpty(operation.Delimiter))
                            return Fail($"{errorPrefix}: the {nameof(operation.Delimiter)} value must be set for a {operationType} text operation.", out error);
                        if (string.IsNullOrWhiteSpace(search.Raw))
                            return Fail($"{errorPrefix}: the {nameof(operation.Search)} value must be set for a {operationType} text operation.", out error);

                        parsedOperation = new RemoveDelimitedTextOperation(
                            operation: operationType,
                            target: target,
                            search: search,
                            delimiter: operation.Delimiter,
                            replaceMode: replaceMode
                        );
                        break;

                    default:
                        return Fail($"{errorPrefix}: unsupported text operation type '{operationType}'", out error);
                }

                // create text operation entry
                textOperations.Add(parsedOperation);
            }

            error = null;
            return true;
        }

        /// <summary>Parse the data change fields for an <see cref="PatchType.EditData"/> patch.</summary>
        /// <param name="entry">The change to load.</param>
        /// <param name="tokenParser">Handles low-level parsing and validation for tokens.</param>
        /// <param name="assumeModIds">Mod IDs to assume are installed for purposes of token validation.</param>
        /// <param name="path">The path to the value from the root content file.</param>
        /// <param name="entries">The parsed data entry changes.</param>
        /// <param name="fields">The parsed data field changes.</param>
        /// <param name="moveEntries">The parsed move entry records.</param>
        /// <param name="targetField">The field within the data asset to which edits should be applied, or empty to apply to the root asset.</param>
        /// <param name="error">The error message indicating why parsing failed, if applicable.</param>
        /// <returns>Returns whether parsing succeeded.</returns>
        private bool TryParseEditDataFields(PatchConfig entry, TokenParser tokenParser, IInvariantSet assumeModIds, LogPathBuilder path, out List<EditDataPatchRecord> entries, out List<EditDataPatchField> fields, out List<EditDataPatchMoveRecord> moveEntries, out List<IManagedTokenString> targetField, [NotNullWhen(false)] out string? error)
        {
            entries = [];
            fields = [];
            moveEntries = [];
            targetField = [];

            bool Fail(string reason, out string outReason)
            {
                outReason = reason;
                return false;
            }

            // parse entries
            foreach ((string entryId, JToken? entryObj) in entry.Entries)
            {
                LogPathBuilder localPath = path.With(nameof(entry.Entries), entryId);

                if (!tokenParser.TryParseString(entryId, assumeModIds, localPath.With("key"), out string? keyError, out IManagedTokenString? key))
                    return Fail($"{nameof(PatchConfig.Entries)} > '{entryId}' key is invalid: {keyError}", out error);
                if (!tokenParser.TryParseJson(entryObj, assumeModIds, localPath.With("value"), out string? valueError, out TokenizableJToken? value))
                    return Fail($"{nameof(PatchConfig.Entries)} > '{entryId}' value is invalid: {valueError}", out error);

                entries.Add(new EditDataPatchRecord(key, value));
            }

            // parse fields
            foreach ((string entryId, Dictionary<string, JToken?>? entryFields) in entry.Fields)
            {
                if (entryFields is null)
                    continue;

                LogPathBuilder localPath = path.With(nameof(entry.Fields), entryId);

                // parse entry key
                if (!tokenParser.TryParseString(entryId, assumeModIds, localPath.With("key"), out string? keyError, out IManagedTokenString? key))
                    return Fail($"{nameof(PatchConfig.Fields)} > entry {entryId} is invalid: {keyError}", out error);

                // parse fields
                foreach ((string fieldId, JToken? field) in entryFields)
                {
                    // parse field key
                    if (!tokenParser.TryParseString(fieldId, assumeModIds, localPath.With(fieldId, "key"), out string? fieldError, out IManagedTokenString? fieldKey))
                        return Fail($"{nameof(PatchConfig.Fields)} > entry {entryId} > field {fieldId} key is invalid: {fieldError}", out error);

                    // parse value
                    if (!tokenParser.TryParseJson(field, assumeModIds, localPath.With(fieldId, "value"), out string? valueError, out TokenizableJToken? value))
                        return Fail($"{nameof(PatchConfig.Fields)} > entry {entryId} > field {fieldKey} is invalid: {valueError}", out error);
                    if (value?.IsString == true && value.GetTokenStrings().SelectMany(p => p.LexTokens).OfType<LexTokenLiteral>().Any(p => p.Text.Contains("/")))
                        return Fail($"{nameof(PatchConfig.Fields)} > entry {entryId} > field {fieldKey} is invalid: value can't contain field delimiter character '/'", out error);

                    fields.Add(new EditDataPatchField(key, fieldKey, value));
                }
            }

            // parse move entries
            {
                int i = 0;
                foreach (PatchMoveEntryConfig? moveEntry in entry.MoveEntries)
                {
                    if (moveEntry is null)
                        continue;

                    LogPathBuilder localPath = path.With(nameof(entry.MoveEntries), i++.ToString());

                    // validate
                    string?[] targets = [moveEntry.BeforeID, moveEntry.AfterID, moveEntry.ToPosition];
                    if (string.IsNullOrWhiteSpace(moveEntry.ID))
                        return Fail($"{nameof(PatchConfig.MoveEntries)} > move entry is invalid: must specify an {nameof(PatchMoveEntryConfig.ID)} value", out error);
                    switch (targets.Count(p => !string.IsNullOrWhiteSpace(p)))
                    {
                        case 0:
                            return Fail($"{nameof(PatchConfig.MoveEntries)} > entry '{moveEntry.ID}' is invalid: must specify one of {nameof(PatchMoveEntryConfig.ToPosition)}, {nameof(PatchMoveEntryConfig.BeforeID)}, or {nameof(PatchMoveEntryConfig.AfterID)}", out error);
                        case > 1:
                            return Fail($"{nameof(PatchConfig.MoveEntries)} > entry '{moveEntry.ID}' is invalid: must specify only one of {nameof(PatchMoveEntryConfig.ToPosition)}, {nameof(PatchMoveEntryConfig.BeforeID)}, and {nameof(PatchMoveEntryConfig.AfterID)}", out error);
                    }

                    // parse IDs
                    if (!tokenParser.TryParseString(moveEntry.ID, assumeModIds, localPath.With(nameof(moveEntry.ID)), out string? idError, out IManagedTokenString? moveId))
                        return Fail($"{nameof(PatchConfig.MoveEntries)} > entry '{moveEntry.ID}' > {nameof(PatchMoveEntryConfig.ID)} is invalid: {idError}", out error);
                    if (!tokenParser.TryParseString(moveEntry.BeforeID, assumeModIds, localPath.With(nameof(moveEntry.BeforeID)), out string? beforeIdError, out IManagedTokenString? beforeId))
                        return Fail($"{nameof(PatchConfig.MoveEntries)} > entry '{moveEntry.ID}' > {nameof(PatchMoveEntryConfig.BeforeID)} is invalid: {beforeIdError}", out error);
                    if (!tokenParser.TryParseString(moveEntry.AfterID, assumeModIds, localPath.With(nameof(moveEntry.AfterID)), out string? afterIdError, out IManagedTokenString? afterId))
                        return Fail($"{nameof(PatchConfig.MoveEntries)} > entry '{moveEntry.ID}' > {nameof(PatchMoveEntryConfig.AfterID)} is invalid: {afterIdError}", out error);

                    // parse position
                    MoveEntryPosition toPosition = MoveEntryPosition.None;
                    if (!string.IsNullOrWhiteSpace(moveEntry.ToPosition) && (!Enum.TryParse(moveEntry.ToPosition, true, out toPosition) || toPosition == MoveEntryPosition.None))
                        return Fail($"{nameof(PatchConfig.MoveEntries)} > entry '{moveEntry.ID}' > {nameof(PatchMoveEntryConfig.ToPosition)} is invalid: must be one of {nameof(MoveEntryPosition.Bottom)} or {nameof(MoveEntryPosition.Top)}", out error);

                    // create move entry
                    moveEntries.Add(new EditDataPatchMoveRecord(moveId, beforeId, afterId, toPosition));
                }
            }

            // parse target field
            {
                int i = 0;
                foreach (string fieldName in entry.TargetField)
                {
                    LogPathBuilder localPath = path.With(nameof(entry.TargetField), i++.ToString());

                    if (!tokenParser.TryParseString(fieldName, assumeModIds, localPath, out string? fieldError, out IManagedTokenString? fieldKey))
                        return Fail($"{nameof(PatchConfig.Fields)} > target path {i - 1} is invalid: {fieldError}", out error);

                    targetField.Add(fieldKey);
                }
            }

            error = null;
            return true;
        }

        /// <summary>Normalize and parse the given condition values.</summary>
        /// <param name="name">The raw condition name.</param>
        /// <param name="value">The raw condition value.</param>
        /// <param name="tokenParser">Handles low-level parsing and validation for tokens.</param>
        /// <param name="path">The path to the condition from the root content file.</param>
        /// <param name="condition">The normalized condition.</param>
        /// <param name="immutableRequiredModIDs">The mod IDs always available when the condition is applied. If the condition has an immutable <see cref="ConditionType.HasMod"/> condition, it'll be added to this list.</param>
        /// <param name="error">An error message indicating why normalization failed.</param>
        private bool TryParseCondition(string? name, string? value, TokenParser tokenParser, LogPathBuilder path, [NotNullWhen(true)] out Condition? condition, ref MutableInvariantSet immutableRequiredModIDs, [NotNullWhen(false)] out string? error)
        {
            bool Fail(string reason, out string setError, out Condition? setCondition)
            {
                setCondition = null;
                setError = reason;
                return false;
            }

            // parse condition key
            LexTokenToken keyLexToken;
            {
                // get lexical tokens
                ILexToken[] lexTokens = this.Lexer.ParseBits(name, impliedBraces: true).ToArray();
                for (int i = 0; i < lexTokens.Length; i++)
                {
                    if (!tokenParser.Migrator.TryMigrate(ref lexTokens[i], out error))
                        return Fail(error, out error, out condition);
                }

                // parse condition key
                if (lexTokens.Length != 1 || lexTokens[0] is not LexTokenToken lexToken)
                    return Fail($"'{name}' isn't a valid token name", out error, out condition);
                keyLexToken = lexToken;
            }
            IManagedTokenString? keyInputStr = tokenParser.CreateTokenStringOrNull(keyLexToken.InputArgs?.Parts, tokenParser.Context, path.With("key"));
            IInputArguments keyInputArgs = tokenParser.CreateInputArgs(keyInputStr);

            // This will validate if the token exists, and if not, is it allowed from HasMod checks
            if (!tokenParser.TryValidateToken(keyLexToken, assumeModIds: immutableRequiredModIDs.GetImmutable(), out error))
                return Fail(error, out error, out condition);

            // get token
            IToken? token = tokenParser.Context.GetToken(keyLexToken.Name, enforceContext: false);

            // validate input
            if (token != null && !token.TryValidateInput(keyInputArgs, out error))
                return Fail(error, out error, out condition);

            // parse values
            if (string.IsNullOrWhiteSpace(value))
                return Fail($"can't parse condition {name}: value can't be empty", out error, out condition);
            if (!tokenParser.TryParseString(value, assumeModIds: immutableRequiredModIDs.GetImmutable(), path.With("value"), out error, out IManagedTokenString? values))
                return Fail($"can't parse condition {name}: {error}", out error, out condition);

            // validate token keys & values
            if (!values.IsMutable && values.IsReady && token != null && !token.TryValidateValues(keyInputArgs, values.SplitValuesUnique(token.NormalizeValue), tokenParser.Context, out string? customError))
                return Fail($"invalid {keyLexToken.Name} condition: {customError}", out error, out condition);

            // create condition
            condition = new Condition(name: token?.Name ?? keyLexToken.Name, input: keyInputStr, values: values, isTokenMutable: token?.IsMutable ?? false);
            if (!tokenParser.Migrator.TryMigrate(condition, out error))
                return Fail(error, out error, out condition);

            // extract HasMod required IDs if immutable
            if (condition.IsReady && !condition.IsMutable && condition.Is(ConditionType.HasMod))
            {
                // contains
                if (condition.Input.ReservedArgs.TryGetValue(InputArguments.ContainsKey, out IInputArgumentValue? contains))
                {
                    if (bool.TryParse(condition.Values.Value, out bool required) && required)
                        immutableRequiredModIDs.AddMany(contains.Parsed);
                }

                // values
                else
                    immutableRequiredModIDs.AddMany(condition.CurrentValues);
            }

            return true;
        }

        /// <summary>Parse a boolean value from a string which can contain tokens, and validate that it's valid.</summary>
        /// <param name="rawValue">The raw string which may contain tokens.</param>
        /// <param name="tokenParser">The  tokens available for this content pack.</param>
        /// <param name="assumeModIds">Mod IDs to assume are installed for purposes of token validation.</param>
        /// <param name="path">The path to the value from the root content file.</param>
        /// <param name="error">An error phrase indicating why parsing failed (if applicable).</param>
        /// <param name="parsed">The parsed value.</param>
        private bool TryParseBoolean(string? rawValue, TokenParser tokenParser, IInvariantSet assumeModIds, LogPathBuilder path, [NotNullWhen(false)] out string? error, [NotNullWhen(true)] out IManagedTokenString? parsed)
        {
            // analyze string
            if (!tokenParser.TryParseString(rawValue, assumeModIds, path, out error, out parsed))
                return false;

            // validate & extract tokens
            if (parsed.HasAnyTokens)
            {
                // only one token allowed
                if (!parsed.IsSingleTokenOnly)
                {
                    error = "can't be treated as a true/false value because it contains multiple tokens.";
                    return false;
                }

                // parse token
                LexTokenToken lexToken = parsed.GetTokenPlaceholders(recursive: false).Single();
                IToken? token = tokenParser.Context.GetToken(lexToken.Name, enforceContext: false);
                IInputArguments input = tokenParser.CreateInputArgs(
                    tokenParser.CreateTokenStringOrNull(lexToken.InputArgs?.Parts, tokenParser.Context, path.With("input"))
                );

                // check token options
                if (token == null)
                {
                    error = $"unknown token '{lexToken.Name}'.";
                    return false;
                }
                if (!token.BypassesContextValidation)
                {
                    if (!token.HasBoundedValues(input, out IInvariantSet? allowedValues) || !allowedValues.All(p => bool.TryParse(p, out _)))
                    {
                        error = "that token isn't restricted to 'true' or 'false'.";
                        return false;
                    }
                    if (token.CanHaveMultipleValues(input))
                    {
                        error = "can't be treated as a true/false value because that token can have multiple values.";
                        return false;
                    }
                }
            }

            // parse text
            return true;
        }

        /// <summary>Parse a boolean <see cref="PatchConfig.Enabled"/> value from a string which can contain tokens, and validate that it's valid.</summary>
        /// <param name="rawValue">The raw string which may contain tokens.</param>
        /// <param name="tokenParser">The  tokens available for this content pack.</param>
        /// <param name="assumeModIds">Mod IDs to assume are installed for purposes of token validation.</param>
        /// <param name="path">The path to the value from the root content file.</param>
        /// <param name="error">An error phrase indicating why parsing failed (if applicable).</param>
        /// <param name="parsed">The parsed value.</param>
        private bool TryParseEnabled(string? rawValue, TokenParser tokenParser, IInvariantSet assumeModIds, LogPathBuilder path, [NotNullWhen(false)] out string? error, out bool parsed)
        {
            parsed = false;

            // analyze string
            if (!this.TryParseBoolean(rawValue, tokenParser, assumeModIds, path, out error, out IManagedTokenString? tokenString))
                return false;

            // validate that it has no tokens
            if (tokenString.HasAnyTokens)
            {
                error = "cannot contain tokens.";
                return false;
            }

            // parse as boolean
            if (!bool.TryParse(rawValue, out parsed))
            {
                error = $"can't parse {tokenString.Raw} as a true/false value.";
                return false;
            }
            return true;
        }

        /// <summary>Parse a tokenizable position from its parts, and validate that it's valid.</summary>
        /// <param name="raw">The raw position to parse.</param>
        /// <param name="tokenParser">The tokens available for this content pack.</param>
        /// <param name="assumeModIds">Mod IDs to assume are installed for purposes of token validation.</param>
        /// <param name="path">The path to the value from the root content file.</param>
        /// <param name="error">An error phrase indicating why parsing failed (if applicable).</param>
        /// <param name="parsed">The parsed value.</param>
        private bool TryParsePosition(PatchPositionConfig? raw, TokenParser tokenParser, IInvariantSet assumeModIds, LogPathBuilder path, [NotNullWhen(false)] out string? error, [NotNullWhen(true)] out TokenPosition? parsed)
        {
            bool TryParseField(string? rawField, string name, [NotNullWhen(true)] out IManagedTokenString? result, [NotNullWhen(false)] out string? parseError)
            {
                if (!this.TryParseInt(rawField, tokenParser, assumeModIds, path.With(name), out parseError, out result))
                {
                    parseError = $"invalid {name}: {parseError}";
                    return false;
                }
                return true;
            }

            if (raw is null)
            {
                error = "the tile position is required";
                parsed = null;
                return false;
            }

            if (
                !TryParseField(raw.X, nameof(raw.X), out IManagedTokenString? x, out error)
                || !TryParseField(raw.Y, nameof(raw.Y), out IManagedTokenString? y, out error)
            )
            {
                parsed = null;
                return false;
            }

            parsed = new TokenPosition(x, y);
            return true;
        }

        /// <summary>Parse a tokenizable rectangle from its parts, and validate that it's valid.</summary>
        /// <param name="raw">The raw rectangle to parse.</param>
        /// <param name="tokenParser">The tokens available for this content pack.</param>
        /// <param name="assumeModIds">Mod IDs to assume are installed for purposes of token validation.</param>
        /// <param name="path">The path to the value from the root content file.</param>
        /// <param name="error">An error phrase indicating why parsing failed (if applicable).</param>
        /// <param name="parsed">The parsed value.</param>
        private bool TryParseRectangle(PatchRectangleConfig raw, TokenParser tokenParser, IInvariantSet assumeModIds, LogPathBuilder path, [NotNullWhen(false)] out string? error, [NotNullWhen(true)] out TokenRectangle? parsed)
        {
            bool TryParseField(string? rawField, string name, [NotNullWhen(true)] out IManagedTokenString? result, [NotNullWhen(false)] out string? parseError)
            {
                if (!this.TryParseInt(rawField, tokenParser, assumeModIds, path.With(name), out parseError, out result))
                {
                    parseError = $"invalid {name}: {parseError}";
                    return false;
                }
                return true;
            }

            if (
                !TryParseField(raw.X, nameof(raw.X), out IManagedTokenString? x, out error)
                || !TryParseField(raw.Y, nameof(raw.Y), out IManagedTokenString? y, out error)
                || !TryParseField(raw.Width, nameof(raw.Width), out IManagedTokenString? width, out error)
                || !TryParseField(raw.Height, nameof(raw.Height), out IManagedTokenString? height, out error)
            )
            {
                parsed = null;
                return false;
            }

            parsed = new TokenRectangle(x, y, width, height);
            return true;
        }

        /// <summary>Parse an integer value from a string which can contain tokens, and validate that it's valid.</summary>
        /// <param name="rawString">The raw string which may contain tokens.</param>
        /// <param name="tokenParser">The  tokens available for this content pack.</param>
        /// <param name="assumeModIds">Mod IDs to assume are installed for purposes of token validation.</param>
        /// <param name="path">The path to the value from the root content file.</param>
        /// <param name="error">An error phrase indicating why parsing failed (if applicable).</param>
        /// <param name="parsed">The parsed value.</param>
        private bool TryParseInt(string? rawString, TokenParser tokenParser, IInvariantSet assumeModIds, LogPathBuilder path, [NotNullWhen(false)] out string? error, [NotNullWhen(true)] out IManagedTokenString? parsed)
        {
            parsed = null;

            // analyze string
            if (!tokenParser.TryParseString(rawString, assumeModIds, path, out error, out IManagedTokenString? tokenString))
                return false;

            // validate tokens
            if (tokenString.HasAnyTokens)
            {
                // only one token allowed
                if (!tokenString.IsSingleTokenOnly)
                {
                    error = "can't be treated as a number because it contains multiple tokens.";
                    return false;
                }

                // parse token
                LexTokenToken lexToken = tokenString.GetTokenPlaceholders(recursive: false).Single();
                IToken? token = tokenParser.Context.GetToken(lexToken.Name, enforceContext: false);
                if (token is null)
                {
                    error = $"unknown token {lexToken.Name}";
                    return false;
                }
                IInputArguments input = tokenParser.CreateInputArgs(
                    tokenParser.CreateTokenStringOrNull(lexToken.InputArgs?.Parts, tokenParser.Context, path.With("input"))
                );

                // check token options
                if (!token.BypassesContextValidation)
                {
                    bool isIntegerBounded =
                        token.HasBoundedRangeValues(input, out _, out _)
                        || (token.HasBoundedValues(input, out IInvariantSet? allowedValues) && allowedValues.All(p => int.TryParse(p, out _)));

                    if (!isIntegerBounded)
                    {
                        error = "that token isn't restricted to integers.";
                        return false;
                    }
                    if (token.CanHaveMultipleValues(input))
                    {
                        error = "can't be treated as a number because that token can have multiple values.";
                        return false;
                    }
                }
            }

            parsed = tokenString;

            return true;
        }

        /// <summary>Prepare a local asset file for a patch to use.</summary>
        /// <param name="path">The asset path in the content patch.</param>
        /// <param name="tokenParser">Handles low-level parsing and validation for tokens.</param>
        /// <param name="assumeModIds">Mod IDs to assume are installed for purposes of token validation.</param>
        /// <param name="logPath">The path to the value from the root content file.</param>
        /// <param name="error">The error reason if preparing the asset fails.</param>
        /// <param name="tokenedPath">The parsed value.</param>
        /// <returns>Returns whether the local asset was successfully prepared.</returns>
        private bool TryPrepareLocalAsset(string? path, TokenParser tokenParser, IInvariantSet assumeModIds, LogPathBuilder logPath, [NotNullWhen(false)] out string? error, [NotNullWhen(true)] out IManagedTokenString? tokenedPath)
        {
            // normalize raw value
            path = path?.Trim();
            if (string.IsNullOrWhiteSpace(path))
            {
                error = $"must set the {nameof(PatchConfig.FromFile)} field for this action type.";
                tokenedPath = null;
                return false;
            }

            // tokenize
            if (!tokenParser.TryParseString(path, assumeModIds, logPath, out string? tokenError, out tokenedPath))
            {
                error = $"the {nameof(PatchConfig.FromFile)} is invalid: {tokenError}";
                tokenedPath = null;
                return false;
            }

            // looks OK
            error = null;
            return true;
        }

        /// <summary>Get whether a patch was loaded directly or indirectly by a parent patch.</summary>
        /// <param name="parent">The parent patch.</param>
        /// <param name="child">The child patch.</param>
        private bool IsDescendant(IPatch parent, IPatch child)
        {
            for (IPatch? cur = child.ParentPatch; cur != null; cur = cur.ParentPatch)
            {
                if (object.ReferenceEquals(cur, parent))
                    return true;
            }

            return false;
        }

        /// <summary>Get a key which sorts conditions into the order they should be parsed.</summary>
        /// <param name="key">The condition key.</param>
        /// <param name="value">The condition value.</param>
        private int GetConditionParseOrder(string key, string? value)
        {
            // parse HasMod conditions first (they allow mod-provided tokens in other conditions)
            if (key.Contains(nameof(ConditionType.HasMod)))
            {
                return !this.Lexer.MightContainTokens(key) && !this.Lexer.MightContainTokens(value)
                    ? 1 // check immutable conditions first
                    : 2;
            }

            // any other condition
            return 3;
        }
    }
}
